Skip to content

feat(api): pages and widgets on api/2 - #3133

Open
rubenvdlinde wants to merge 2 commits into
feat/noid/grid-widget-lifecyclefrom
feat/noid/grid-pages-api
Open

rubenvdlinde wants to merge 2 commits into
feat/noid/grid-widget-lifecyclefrom
feat/noid/grid-pages-api

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Oct 22, 2026 •

Copy link
Copy Markdown

Part of #3099, on top of #3132 (widget lifecycle). This adds the five endpoints from #3100. They're routed with #[ApiRoute] on the controller, like the newer public endpoints, so routes.php doesn't change.

Size: 3562 lines: 729 code, 544 tests, and 2289 generated OpenAPI (openapi.json and openapi.ts) in a separate commit you can skip.

Endpoint What it does Who may
GET /api/2/contexts/{contextId}/pages/{pageId} A page with its widgets Read access to the application
POST /api/2/contexts/{contextId}/pages Add a grid page (title, icon, description, technical name) Manage access
PATCH /api/2/contexts/{contextId}/pages/{pageId} Change the page, its widgets, or both Manage access
DELETE /api/2/contexts/{contextId}/pages/{pageId} Delete a grid page Manage access
GET /api/2/widget-types The widget types and their settings schemas Any logged-in user
  • Validation. Every widget's settings are checked against its type's schema. Unknown settings are dropped and missing ones get their default.
  • Data widgets. A data widget shows a table or view that's already in its application: its source is {nodeRelId} of that application. Anything else is refused with a 400, with the same message whether the table doesn't exist or just isn't in the application.
  • Technical names. Leave it out and the page gets page_<id>. An invalid one, or one already used in the application, gives a 400.
  • Icons must be a known icon name; anything else gives a 400.
  • The start page can't be deleted (400).
  • Limits. At most 100 widgets per page. Adding pages is limited to 20 a minute per user, changing a page to 60.
  • Two saves at the same time that collide give a 400 asking to load the page again, not a 500.
  • Someone outside the application gets a 404, as with the other application endpoints, so outsiders can't tell whether an application exists. Reading needs only read access, which every member has, so reading a page has no 403.

OpenAPI and the TypeScript types are regenerated, in a separate commit.

Saving a page never changes what's in the application, so it can't give the application's sharees access to another table. To show a table that isn't in the application yet, add it to the application first.

How it was tested

On Nextcloud 34 with PostgreSQL:

  • PageServiceTest, including that someone outside the application gets a 404 before any write, and that a source outside the application is refused
  • ContextPageControllerTest: every endpoint, and 400, 403 and 404
  • changing or deleting a page of another application gives a 404
  • two saves colliding
  • WidgetServiceTest and GridWidgetTypesTest
  • the full unit suite on the top of the stack (feat(contexts): carry the menu through export and import #3138), on PostgreSQL and SQLite: 684 tests
  • composer cs:check, psalm and rector, all clean

By hand on a test instance:

  • The owner can add, change and read a page, with a data widget on a table of the application.
  • A widget title can be cleared.
  • A table from outside the application gives a 400, and so does deleting the start page.
  • A read-only sharee can read (200) and gets a 403 on every change.
  • Someone outside the application gets a 404 on all four page endpoints.
  • Deleting a page leaves no widget or source rows behind.

🖼️ Screenshots

Backend only, no UI changes. The frontend follows in separate pull requests.

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔙 Backport requests are created or not needed: /backport to stableX.X
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

Comment thread appinfo/routes.php Outdated
Comment on lines +163 to +167
['name' => 'ContextPage#create', 'url' => '/api/2/contexts/{contextId}/pages', 'verb' => 'POST'],
['name' => 'ContextPage#show', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'GET'],
['name' => 'ContextPage#update', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PATCH'],
['name' => 'ContextPage#destroy', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'DELETE'],
['name' => 'ContextPage#widgetTypes', 'url' => '/api/2/widget-types', 'verb' => 'GET'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I see, new routes are now added in the controllers, not in the routes.php.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rjzondervan, agreed. The routes are now declared on the controller with #[ApiRoute], and this PR no longer touches appinfo/routes.php.

@rubenvdlinde
rubenvdlinde force-pushed the feat/noid/grid-pages-api branch from daeab7e to a812d77 Compare October 24, 2026 08:09
@rubenvdlinde
rubenvdlinde force-pushed the feat/noid/grid-pages-api branch from a812d77 to e241b46 Compare October 26, 2026 09:24
@rubenvdlinde
rubenvdlinde marked this pull request as ready for review October 26, 2026 09:25
Comment thread lib/Service/PageService.php Outdated
Comment on lines +324 to +333
$nodeType = $source['nodeType'] ?? null;
$nodeId = $source['nodeId'] ?? null;
if (!in_array($nodeType, [Application::NODE_TYPE_TABLE, Application::NODE_TYPE_VIEW], true) || !is_int($nodeId) || $nodeId <= 0) {
throw new BadRequestError('The source of widget ' . $index . ' needs a nodeRelId of this application, or a nodeType and nodeId of a table or view.');
}
$key = $this->nodeKey($nodeType, $nodeId);
if (isset($relIdsByNodeKey[$key])) {
return [$relIdsByNodeKey[$key], null];
}
return [null, ['nodeType' => $nodeType, 'nodeId' => $nodeId]];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we agree that a data widget can only show a table or view that is already in the application? This seems to ignore that, and lets a page save add new tables to the application, which also gives every sharee of the application read access to them. The application owner also gets full rights to any table in the application 🤔

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we agree. Saving a page no longer adds anything to the application. A data widget's source has to be {nodeRelId} of a table or view that's already in it; anything else is a 400, with the same message whether the table doesn't exist or just isn't in the application. The {nodeType, nodeId} form is gone, with its tests.

Comment on lines +212 to +217
private function getManageableContext(int $contextId, string $userId): Context {
$context = $this->contextMapper->findById($contextId, $userId);
if (!$this->permissionsService->canManageContextById($contextId, $userId)) {
throw new PermissionError(sprintf('User %s cannot manage application %d', $userId, $contextId));
}
return $context;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we check the permission with canManageContext($context, $userId), using the application we already loaded here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we don't have to find it first. Seems like that should work

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, it uses canManageContext() with the application loaded just before.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have the checks on the Controller layer, which should be sufficient. They can be dropped here to save some CPU cycles and DB queries, assuming that behind the external guards, internal handling is controlled and safe.

* 403: No permissions
* 404: Not found
*
* @CanManageContext

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't #[RequirePermission] already does the same check.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does. I removed the @CanManageContext annotation here and on destroy().

* 403: No permissions on the application or on a table or view to add
* 404: Not found
*
* @CanManageContext

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't #[RequirePermission] already does the same check?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, removed here too.

Comment on lines +107 to +124
* [api v2] Change a page or its widgets
*
* Fields left out keep their value; an empty icon or description removes it. A new
* technical name must be valid and free in the application. When
* `widgets` is given, the widgets of the grid page are made to match it in one
* transaction: listed widgets with an `id` are updated, listed widgets without one
* are added and widgets that are not listed are deleted.
*
* A widget has a `type`, an optional `title`, `icon` and `description`, `gridX`, `gridY`, `gridWidth` and
* `gridHeight` on a grid of 12 columns and `settings` following the schema of its
* type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or
* view of this application, `{nodeType, nodeId}` of a table or view the user manages,
* which is added to the application, or null. A table or view added this way is also
* placed on the start page, and both stay when the widget is removed later.
*
* A page holds at most 100 widgets. When another save changed the page in between,
* for instance removed a listed widget, the save is refused and can be retried on the
* page as it is now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too long, which adds clutter. The code should be readable enough to not need long comments. Also, it's very easy for comments that reference code properties to become outdated and just confusing

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. It's down to what the code can't tell an API user: what a widgets list does to the page, and what a widget looks like. I shortened the comments in the other files I touched as well.

Comment on lines +55 to +56
* 200: Page returned
* 404: Not found

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No 403 why?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's on purpose. Reading a page only needs read access, which everyone in the application has. Someone outside it gets a 404 from the permission middleware, like on the other application endpoints, so outsiders can't tell whether an application exists. I added that to the 404 description, so the API docs say it too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment as at https://github.com/nextcloud/tables/pull/3131/changes#r4431715470 about all these tests

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done here as well: 44 test methods down to 25, and 677 test lines down to 544. The controller tests are now one data provider for the endpoints and one for the errors; the checks on route and rate limit attributes are gone.

Read, add, change and delete grid pages with their widgets, and list
the widget types with their settings schemas. Reading needs read
access to the application, changes need manage access. Page creation
is limited to 20 a minute per user.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Ruben van der Linde <ruben@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Ruben van der Linde <ruben@conduction.nl>
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Hello there,
Thank you so much for taking the time and effort to create a pull request to our Nextcloud project.

We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process.

Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6

Thank you for contributing to Nextcloud and we hope to hear from you soon!

(If you believe you should not receive this message, you can add yourself to the blocklist.)

@blizzz blizzz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking is the JSON Type. Also, message must be made translatable, this can be a follow up though.

Comment thread lib/Db/Widget.php
*/
public function setSettingsArray(array $settings): void {
$this->setSettings(json_encode((object)$settings, JSON_THROW_ON_ERROR));
$this->addType('settings', Types::JSON);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

string (or array) not JSON

class PageService {
use TTransactional;

private const CHANGED_IN_BETWEEN = 'The page was changed by another save in the meantime. Load it again and retry.';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not translatable, but sounds (end) user facing.

Comment on lines +212 to +217
private function getManageableContext(int $contextId, string $userId): Context {
$context = $this->contextMapper->findById($contextId, $userId);
if (!$this->permissionsService->canManageContextById($contextId, $userId)) {
throw new PermissionError(sprintf('User %s cannot manage application %d', $userId, $contextId));
}
return $context;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have the checks on the Controller layer, which should be sufficient. They can be dropped here to save some CPU cycles and DB queries, assuming that behind the external guards, internal handling is controlled and safe.

try {
$save();
} catch (\InvalidArgumentException $e) {
throw new BadRequestError(self::CHANGED_IN_BETWEEN, 0, $e);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could also result in a 409?

if ($newTechnicalName !== null) {
throw new BadRequestError('The technical name ' . $newTechnicalName . ' is already used by a page of this application.', 0, $e);
}
throw new BadRequestError(self::CHANGED_IN_BETWEEN, 0, $e);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

409 also here.


$id = $widget['id'] ?? null;
if ($id !== null && (!is_int($id) || !isset($existingIds[$id]) || isset($seenIds[$id]))) {
throw new BadRequestError('Widget ' . $index . ' has the id of no other widget on this page.');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the index (here and elsewhere) to the end user? Also, all user facing messages have to be translateable.

@AndyScherzinger AndyScherzinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review focused on performance, accessibility, security, clean code and architecture. The inline comments only cover points that aren't in the existing threads here, on #3132 or on #3131; where one follows from an earlier thread, it links to it. This PR has no UI of its own, so the accessibility points are about what the API accepts and how the page bar in #3215 shows it. Points marked as reproduced or simulated were checked on a dev instance (MariaDB) running this branch.

if (count($widgets) > GridWidgetTypes::MAX_WIDGETS_PER_PAGE) {
throw new BadRequestError('A page holds at most ' . GridWidgetTypes::MAX_WIDGETS_PER_PAGE . ' widgets.');
}
$existingIds = array_flip($this->widgetService->findIdsByPageId($pageId));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A widgets list replaces all widgets of the page, and the only conflict this detects is a change that lands while the request runs. A save sent from a page loaded before another save went through is taken as it is: widgets the other save added are deleted and its changes to the listed widgets are overwritten, with a 200. In #3215 GridPage.vue copies the page into a draft when editing starts and sends every widget of the draft on save, so two people editing the same page (or one person in two tabs) undo each other's work without a message, and the window is the whole edit session.

Reproduced on the dev instance: a save that added a header returned 200; a second save built from the page before it, changing another widget's text, also returned 200, and the new header was gone. The one stale case that is caught, a listed widget the other save removed, is answered with "Widget 0 has the id of no other widget on this page." from the check below instead of the "Load it again and retry" message CHANGED_IN_BETWEEN has for it.

For saves that do overlap, saveChanges() decides by what the request asked for rather than by what failed: a request that renames the page and collides with another save on a widget's source key is told its new technical name is taken (only an API client sends both in one request), and any other database error passes through as a 500. Two saves that update the same widgets in a different order can deadlock, and so can two in the same order when one of them drops a widget the other keeps. Simulated on the dev instance with each save holding its first widget's row lock for a moment: one of the two failed in every run, and not even with the deadlock. MariaDB rolls back the victim's whole transaction, so rolling back to the savepoint of syncPage()'s nested atomic() fails with "SAVEPOINT DOCTRINE_2 does not exist", which replaces the original error and ends as a 500.

A version column on the page (the migration from #3131 isn't released yet), returned in TablesPage and sent back with the save, closes this: the transaction starts with UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?, and 0 affected rows is the "load it again" answer (a 409, as blizzz suggests for saveChanges()). That one statement covers added, changed and removed widgets, serialises two saves of the same page through its row lock, and leaves saveChanges() only the renamed technical name to map.

#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/2/widget-types')]
public function widgetTypes(): DataResponse {
return new DataResponse(array_values($this->gridWidgetTypes->all()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data type has no settings, and json_encode() writes its empty settings array as [], while TablesWidgetType and the generated spec declare an object (additionalProperties: WidgetSettingSchema). On the dev instance GET /api/2/widget-types returns "settings":[] for data and objects for the other two types, so a client generated from openapi.json that maps this to a dictionary (Kotlin, Swift, Dart) can't read the list, and a response validator flags it. The Vue code in the stack only gets by because Object.entries([]) is empty.

Widget::getSettingsObject() and the |\stdClass on TablesWidget.settings already handle this for a widget's own settings. Returning new \stdClass() here only when a type has no settings (typed array<string, TablesWidgetSettingSchema>|\stdClass), and asserting {} for data in testWidgetTypesListsEveryType(), fixes it; casting every schema to an object would lose the non-empty ones in OCS XML (see the comment on Widget::getSettingsObject()).

* @param string|null $technicalName New technical name
* @param string|null $icon New icon name, empty to remove it
* @param string|null $description New description, empty to remove it
* @param list<array<string, mixed>>|null $widgets All widgets of the page

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator turns list<array<string, mixed>> into additionalProperties: {type: object}: openapi.json says every field of a widget is an object, and openapi.ts types each one as Record<string, never>, so a client built from the spec can't send type: 'header' or gridX: 0 without a type error, and the widget shape (which fields, which are required, source: {nodeRelId}) exists only in the prose above.

The generator handles array shapes in parameters ($nodes of ContextController::importScheme() comes out with its fields and required list), so a shape such as list<array{id?: ?int, type: string, title?: ?string, icon?: ?string, description?: ?string, gridX: int, gridY: int, gridWidth: int, gridHeight: int, settings?: ?array<string, TablesWidgetValue>, source?: ?array{nodeRelId: int}}>, best as a named type in ResponseDefinitions (the internal TablesWidgetInput in PageService describes the parsed form), documents what the endpoint accepts. It would also be the natural input type for the value object discussed in #3132 (comment).

Comment on lines +164 to +166
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
#[ApiRoute(verb: 'DELETE', url: '/api/2/contexts/{contextId}/pages/{pageId}', requirements: ['contextId' => '\d+', 'pageId' => '\d+'])]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENTS.md asks for #[UserRateLimit(limit: 20, period: 60)] on mutation endpoints. create() and update() carry one, destroy() doesn't: on the dev instance 25 DELETEs in 16 seconds all returned 200, while the 21st POST within a minute got a 429.

* @throws BadRequestError
*/
private function validateTitle(string $title): string {
$title = trim($title);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gap as #3132 (comment), on the path users take most: on the dev instance POST .../pages with {"title": "\u00a0"} or {"title": "\u200b"} returns 200 and stores that title. In #3215 a page tab gets its name only from the title (pageTitle() returns page.title when it is truthy, the icon is aria-hidden), so such a page shows an empty tab that screen readers announce without a name, which the 1 to 200 characters rule here is meant to prevent.

OCA\Tables\Service\ValueObject\Title already refuses the \u00a0 case for tables, views and columns (ColumnService::normalizeTitle() wraps it; \u200b needs the addition mentioned there), so the shared helper suggested there would cover validateTitle() and validateDescription() here as well. Title counts bytes (strlen() > 200) while this counts characters, so reusing it as it is would shorten the limit for non-Latin titles.

Comment on lines +141 to +143
if ($pageArray['page_type'] !== Page::TYPE_GRID) {
throw new BadRequestError('Only grid pages hold widgets.');
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#3211, stacked on this PR, removes this check again ("The page endpoint accepts widgets on the start page, which still cannot be deleted.") and turns testTheStartPageHoldsNoWidgets() into testTheStartPageHoldsWidgets(). So this 400 only exists between the two PRs, and if this one ships first, API clients see the rule flip between releases. Leaving it out here keeps the contract stable. The delete rule on line 172 then reads better as === Page::TYPE_STARTPAGE, which is what it means; it would also let a page of another type, which imports before #3131 could create, be deleted instead of being refused as the start page.

It also makes the suggestion on #3132 to skip start-page widgets in importWidget() (#3132 (comment)) moot: with #3211 the importer already does what the stack ends up with.

* @throws BadRequestError
* @throws Exception
*/
private function saveNamed(callable $save, string $technicalName): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saveNamed() is saveChanges() with a technical name, minus the \InvalidArgumentException branch that an insert can't reach, so $this->saveChanges(fn () => $this->pageMapper->insert($page), $technicalName) in create() behaves the same and this method can go. The copy has already drifted: it drops the unique violation as previous exception, which saveChanges() keeps for the log. The "already used" message also exists three times (lines 303, 323, 339), and the translation blizzz asked for would have to cover each copy.

Comment on lines +126 to +128
if ($title !== null) {
$page->setTitle($this->validateTitle($title));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A start page gets a title here but can't lose it again: it starts as NULL (ContextService::insertPage()), {"title": "Start"} is accepted, and {"title": ""} is refused by validateTitle(), while an empty icon or description removes the value. The page bar in #3215 shows the translated "Home" only while the title is NULL, so once any client sets one, every user sees that string, in one language, for good. Reproduced on the dev instance (200, then 400). Letting '' reset the start page's title to NULL, or refusing a title for it, keeps the change reversible.

Comment thread lib/Db/Widget.php
*/
public function getSettingsObject(): \stdClass {
$settings = $this->getSettingsArray();
$settings = $this->getSettings() ?? [];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed getSettingsArray() turned a stored value that isn't a JSON object into {}, and the deleted WidgetTest case "not an object" covered that. With Types::JSON and a ?array property, Nextcloud's Entity setter assigns whatever json_decode() returns, so a stored scalar such as "text" now throws a TypeError while the row is hydrated (broken JSON still decodes to null and is fine). The app doesn't write such values itself, but a manual fix, a restored dump or a later migration can, and then the page can't be read, saved or deleted through this API (the controller doesn't catch \Error), and the exports that read its widgets fail too.

Reproduced on the dev instance by setting one widget's settings to "text": GET and DELETE .../pages/{id} answered 500 with "Cannot assign string to property OCA\Tables\Db\Widget::$settings of type ?array", and so did the application's scheme export.

Whatever replaces the JSON type after blizzz's comment above, an is_array() guard where the stored value is decoded keeps one bad row from locking the whole page.

Comment thread lib/Db/Widget.php
$settings = $this->getSettingsArray();
$settings = $this->getSettings() ?? [];
ksort($settings);
return (object)$settings;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The page endpoints return every widget's settings as a \stdClass, and Nextcloud's OCS XML renderer turns any \stdClass into an empty element (BaseResponse::toXML(): if ($v instanceof \stdClass) { $v = []; }). XML is what OCS answers when a client sends neither format=json nor Accept: application/json, so in that format GET, PATCH and DELETE .../pages/{pageId} return <settings/> for every widget and its heading, text and colours are gone. On the dev instance the same page gives {"text": "Edited by A"} for a text widget in JSON and <settings/> in XML.

Returning the sorted array when it has entries and new \stdClass() only when it is empty keeps the JSON output as it is (TablesWidget.settings already allows both) and keeps the values in XML.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants